동시성·스레드 실습 — mutex 락 범위·데드락·조건변수와 메시지 큐·이체 감시 스레드

NOTE

C 기초 강의의 동시성 실습(15-01 ~ 15-15)에서 만난 오류 패턴을 정리하고, 이를 바탕으로 스레드 안전한 메시지 큐(15-00)와 이체·감시 스레드 종합 과제(con_ex.c)를 직접 만들며 겪은 버그를 기록한다.

환경은 Docker Desktop의 Linux 컨테이너(c_app_part1), gcc -Wall -Wextra -pthread.

IMPORTANT

코드는 컨테이너에서 컴파일·실행해 확인했고, 설명은 AI(Claude)와 함께 코드 리뷰하며 정리했다. 컨테이너의 gcc가 4.8.5로 오래돼서 ThreadSanitizer(-fsanitize=thread)와 valgrind를 쓸 수 없었다.

그래서 레이스는 “같은 프로그램을 수백~수천 번 반복 실행해 결과가 어긋나는지” 보는 방식으로 확인했고, 락을 일부러 뺀 변이 버전이 실제로 실패하는지도 같이 봤다. 이 방식은 문제를 발견할 수는 있어도 문제가 없음을 증명하지는 못한다.

TIP

📂 원본 소스 (GitHub)

  • 강의 코드 15-01 ~ 15-15: lecture/conc/
  • 메시지 큐(15-00): conc/msgq/ — 15-00-main.c · 15-00-q.c · 15-00-q.h (원본은 #else, 수정본은 #if MODIFY)
  • 이체·감시 종합 과제: conc/con_ex.c
  • 빌드: make -C c_basic (README), 메시지 큐는 build/conc/msgq/15-00
  • 컨테이너 원본 경로는 ~/coding/15_con/ (접속: docker exec -it c_app_part1 /bin/bash)
  • 폴더 이름이 con이 아니라 conc인 이유: con은 Windows의 예약된 장치 이름이라 git이 그 안의 파일을 열지 못한다.
  • 15-04는 SomeNetworkFile(임의의 텍스트 파일)이 필요하고 용량 때문에 저장소에 없다. 15-03, 15-15는 컴파일되지 않는 상태라 make lecture에서 제외했다.

📌 한눈에 보기

실습문제처방재현 결과
15-01credit()에만 락이 없음 (같은 변수를 두 함수가 접근)변수에 접근하는 모든 경로가 같은 락을 잡는다락 제거 변형 5000회 중 6회 어긋남, 수정본 0회
15-02lock 1번에 unlock 2번 (호출한 함수와 호출된 함수가 둘 다 풂)잡은 함수가 푼다3000회 모두 destroy가 EBUSY(16)
15-03pthread_join 누락만든 스레드는 join(또는 detach)join 없으면 출력 0줄, 있으면 5줄
15-04파일 I/O까지 락 안에 둠락은 공유 변수 접근 구간만(성능 이슈, 측정은 안 함)
15-05return 뒤에 unlock, 읽기/토글에 락 없음값을 담아 두고 unlock 후 return원본은 타입 오류로 빌드가 안 돼 실행은 못 함 (코드상 첫 get_flag() 뒤 락이 안 풀림). 수정본 3000회 정상
15-06관련된 함수 3개가 각자 락 → 합과 곱이 서로 다른 입력 기준락 범위를 논리적 한 단위로 넓힘락 제거 3000회 중 455회 불일치, 수정본 0회
15-07NORMAL 뮤텍스는 잘못된 unlock을 알려 주지 않음ERRORCHECK(개발 중) / RECURSIVENORMAL은 전부 성공, ERRORCHECK는 EPERM
15-08destroy 시점모든 스레드가 join된 뒤—
15-09strtok은 내부 정적 상태를 공유strtok_r2000회 중 1회 합계가 틀림
15-10지역변수 주소를 스레드에 넘기고 함수가 끝남힙에 복사해 넘기고 스레드가 freeResult: 1이 0으로 출력
15-11두 계좌 락을 서로 반대 순서로 잡음락 획득 순서 고정 (id 오름차순)원본 20회 중 2회 정지, 시간차를 주면 항상 정지
15-12cond_signal은 임의의 대기자 하나만 깨움cond_broadcast + while 조건signal 300회 중 1회 정지, broadcast 0회
15-13detach한 스레드를 join한쪽만 선택pthread_join failed
15-14스레드에서 SIGTERM을 받으려는데 핸들러 없음핸들러 등록 + join (또는 sigwait 전용 스레드)핸들러 없으면 프로세스 전체가 종료(143)
15-15PTHREAD_CANCEL_ASYNCHRONOUS + 락deferred 취소 + cleanup, 또는 flag 종료100회 중 44회 main이 락에서 정지
15-00큐에 동기화 없음, 폴링, 센티널mutex + 조건변수 2개 + close_q원본 300회 중 5회 크래시·행, 수정본 2100회 0회
과제이체 4 + 감시 1 + main계좌별 락 + 락 순서 고정 + 전체 스냅샷 락10초 실행 합계 10000 유지

1. 락의 기본 규칙 — 15-01, 15-02, 15-04, 15-05, 15-06

1-1. 같은 변수는 같은 락으로 (15-01)

요약

  • 문제: credit()에만 락이 없어 잔액 갱신이 유실됨 (15-01)
  • 원인: 같은 변수를 두 함수가 접근하는데 한쪽만 잠금. int/unsigned 혼용, 전역 srand도 문제
  • 처방: 변수에 접근하는 모든 경로가 같은 락을 잡고, rc에 결과를 담아 unlock은 한 곳에서. 잔액 타입 통일, rand_r
  • 결과: 락 제거 변형 5000회 중 6회 어긋남, 수정본 0회

1-2. 잡은 함수가 푼다 (15-02)

요약

  • 문제: lock 1번에 unlock 2번 (호출된 verify_balance와 호출한 debit이 둘 다 풂, 15-02)
  • 원인: NORMAL 뮤텍스는 이중 unlock을 에러로 알려 주지 않음
  • 처방: 잡은 함수가 푼다: verify_balance에서 unlock 제거. (return을 if 안에 두는 수정은 오히려 잔액 부족 출금을 허용)
  • 결과: 3000회 모두 destroy가 EBUSY(16) → 수정 후 3000회 통과

1-3. 락 범위는 필요한 만큼만 — 너무 넓어도, 너무 좁아도 (15-04, 15-05, 15-06)

요약

  • 문제: 락 범위가 너무 넓거나(15-04), unlock이 return 뒤에 있거나(15-05), 너무 좁음(15-06)
  • 원인: I/O까지 락 안에 둠 / 토글과 읽기를 따로 잠금 / 합·곱을 각각 잠가 서로 다른 입력 기준
  • 처방: 공유 변수 접근 구간만 잠그고, 논리적 한 단위(확인→변경, 토글→읽기)는 한 번에 잠금. 안쪽 락은 빼거나 RECURSIVE(신중히)
  • 결과: 15-06: 락 제거 3000회 중 455회(약 15%) 불일치, 수정본 0회

2. 뮤텍스 종류와 에러 검사 — 15-07, 15-08

종류별 동작 (컨테이너에서 직접 확인)

요약

  • 문제: NORMAL 뮤텍스는 잘못된 unlock을 조용히 통과시켜 버그가 숨음 (15-07)
  • 원인: 종류별 반응이 다름 (ERRORCHECK는 EPERM/EDEADLK, RECURSIVE는 중복 lock 허용)
  • 처방: 개발 중에는 ERRORCHECK를 명시하고 lock/unlock 반환값을 검사. PTHREAD_MUTEX_INITIALIZER의 조용한 성공에 의존 금지
  • 결과: 컨테이너(glibc) 관찰: NORMAL은 전부 성공, ERRORCHECK는 EPERM

뮤텍스 “종류”와 “개수”는 다른 이야기

destroy 시점 (15-08)

요약

  • 문제: 스레드가 쓰는 중인 뮤텍스를 destroy (15-08)
  • 원인: 뮤텍스 수명이 사용하는 스레드보다 짧음 (객체 생성·삭제마다 init/destroy한 장애 사례)
  • 처방: 모든 스레드를 join한 뒤 destroy, 반환값 != 0은 strerror로 출력

3. 스레드 생명주기 — 15-03, 15-10, 15-13, 15-14, 15-15

3-1. join과 detach (15-03, 15-13)

요약

  • 문제: join 누락(15-03), detach 후 join(15-13)
  • 원인: main이 join 없이 끝나면 sleep 중인 스레드가 같이 죽고, join/detach는 한쪽만 가능
  • 처방: 만든 스레드는 join 또는 detach 중 하나만
  • 결과: join 없으면 출력 0줄/있으면 5줄, detach 후 join은 pthread_join failed

3-2. 지역변수 주소를 넘기지 않는다 (15-10)

요약

  • 문제: 지역변수 주소를 스레드에 넘기고 함수가 끝남 (15-10)
  • 원인: 함수가 반환되면 val의 스택 자리를 다음 호출이 덮어씀
  • 처방: 힙에 복사해 넘기고 스레드가 free (생성 성공이면 스레드, 실패면 만든 쪽이 해제)
  • 결과: 기대한 Result: 1 2 3 4 5 대신 1이 빠지고 0이 출력

3-3. 시그널을 스레드에 보낼 때 (15-14)

요약

  • 문제: pthread_kill(SIGTERM)을 받는 핸들러가 없어 프로세스 전체가 종료 (15-14)
  • 원인: 시그널 처분은 프로세스 단위, 마스크는 스레드 단위
  • 처방: 핸들러(flag만) 등록 + join, 또는 sigwait 전용 스레드(§7-4), pthread_cancel
  • 결과: 핸들러 없으면 종료 코드 143

3-4. 비동기 취소와 락 (15-15)

요약

  • 문제: PTHREAD_CANCEL_ASYNCHRONOUS + 락이 락을 쥔 채 취소되어 main이 영원히 대기 (15-15)
  • 원인: 어느 줄에서나 취소되므로 unlock이 실행되지 않음. deferred라도 루프에 취소 지점이 없으면 취소가 안 먹음
  • 처방: 종료 flag(권장), 또는 deferred + pthread_testcancel() / pthread_cleanup_push(unlock)
  • 결과: 100회 중 44회 main이 락에서 정지

4. 스레드 안전하지 않은 함수 — 15-09

요약

  • 문제: strtok을 여러 스레드에서 호출해 합계가 틀림 (15-09)
  • 원인: strtok이 내부 정적 상태를 공유
  • 처방: strtok_r(상태를 호출자가 보관). rand·localtime·strerror도 _r 버전 사용
  • 결과: 2000회 중 1회 합계가 틀림 (strtok_r은 0회)

5. 조건변수 — 15-12

요약

  • 문제: cond_signal이 임의의 대기자 하나만 깨워 차례인 스레드가 영영 못 깸 (15-12)
  • 원인: 조건 검사를 if로 하거나 signal을 쓰면 깨어난 스레드가 차례가 아닐 수 있음
  • 처방: while로 조건 재검사 + cond_broadcast, cond_wait는 mutex를 쥔 채 호출, 에러 경로에서도 unlock
  • 결과: signal 300회 중 1회 정지, broadcast 0회
flowchart TD
    A["mutex lock"] --> B{"current_step == my_step ?"}
    B -- "아니오" --> W["cond_wait<br/>mutex를 풀고 잠듦"]
    W -- "깨어나면 mutex를 다시 잡음" --> B
    B -- "예" --> C["current_step++"]
    C --> D["cond_broadcast<br/>대기자 전원을 깨움"]
    D --> E["mutex unlock"]

6. 락 획득 순서와 데드락 — 15-11

6-0. 데드락이 생기는 4가지 조건

6-1. 원인: 두 락을 서로 반대 순서로 잡음

요약

  • 문제: 두 락을 스레드마다 반대 순서로 잡아 데드락 (15-11)
  • 원인: 1→2 이체와 2→1 이체가 각자 첫 락을 쥔 채 상대 락을 기다림 (순환 대기)
  • 처방: (§6-2 참고)
  • 결과: 원본 20회 중 2회 정지, 첫 락 직후 sleep(1)을 넣으면 항상 정지
sequenceDiagram
    participant T1 as 스레드 1 (1→2 이체)
    participant A1 as 계좌1 락
    participant A2 as 계좌2 락
    participant T2 as 스레드 2 (2→1 이체)
    T1->>A1: lock 성공
    T2->>A2: lock 성공
    T1-->>A2: lock 대기 (T2가 쥐고 있음)
    T2-->>A1: lock 대기 (T1이 쥐고 있음)
    Note over T1,T2: 서로 상대가 쥔 락을 기다리므로 영원히 정지

6-2. 처방: 획득 순서를 고정한다

요약

  • 원인: 데드락의 원인은 획득 순서이며 해제 순서와 무관
  • 처방: 항상 id가 작은 계좌를 먼저 잡고 자기 자신에게 이체 금지. 해제는 잡은 역순(관례)
  • 결과: 시간차를 줘도 1 -> 2 ok, 2 -> 1 ok, 최종 잔액 995000 / 2005000
flowchart LR
    R["이체 요청 from → to"] --> C{"from.id < to.id ?"}
    C -- "예" --> F1["first = from, second = to"]
    C -- "아니오" --> F2["first = to, second = from"]
    F1 --> L["lock(first) → lock(second)"]
    F2 --> L
    L --> X["확인 → 출금 → 입금"]
    X --> U["unlock(second) → unlock(first)"]

6-3. 에러 경로에서의 락 누락 (15-11의 또 다른 문제)

요약

  • 문제: to 락 획득 실패 시 from 락을 풀지 않고 빠져나감
  • 원인: 락을 둘 이상 잡는 함수에서 단계별 실패 경로의 정리 누락
  • 처방: goto CLEANUP에서 잡은 순서의 역순으로 모두 해제

7. 메시지 큐 라이브러리 — 15-00

7-1. 구조

요약

  • 처방: msg_q_t = 고정 크기(5) 원형 버퍼 + 뮤텍스 1개 + 조건변수 2개(not_empty/not_full) + closed 플래그. 유연 배열 pbuf[]는 맨 마지막 멤버
flowchart LR
    P["producer<br/>enq"] --> L1{"큐가 가득 찼나?"}
    L1 -- "예" --> W1["not_full 대기"]
    W1 --> L1
    L1 -- "아니오" --> Q[("원형 버퍼<br/>front · rear · cur_cnt")]
    Q -- "not_empty 신호" --> C
    C["consumer<br/>deq"] --> L2{"큐가 비었나?"}
    L2 -- "예, closed 아님" --> W2["not_empty 대기"]
    W2 --> L2
    L2 -- "아니오" --> Q
    Q -- "not_full 신호" --> P
    L2 -- "예, closed" --> X(["종료 (NULL 반환)"])

7-2. 원본은 스레드 안전하지 않다

요약

  • 문제: 동기화 없는 큐가 크래시·행·double free를 일으킴 (15-00)
  • 원인: front/rear/cur_cnt를 여러 스레드가 동시에 갱신
  • 처방: (§7-3)
  • 결과: consumer 5개 기준 300회 중 5회(1.7%) 크래시 3·abort 1·행 1

7-3. 수정: 대기는 조건변수로, 종료는 close_q로

요약

  • 문제: 폴링(msleep), 가득 차면 포기하는 센티널 'Q', msleep 단위 오류
  • 원인: 대기를 폴링으로, 종료를 데이터(센티널)로 처리
  • 처방: cond_wait + while, 종료는 close_q(broadcast), closed여도 남은 데이터는 drain 후 CLOSED 반환
  • 결과: 수정본 2100회 0회 실패 (recv 정확히 50줄, a~y 각 2번, 종료 코드 0)

7-4. SIGINT로 종료하기 — sigwait 전용 스레드

요약

  • 문제: SIGINT 핸들러에서 close_q()를 직접 호출하면 자기 자신과 데드락
  • 원인: pthread_mutex_lock·cond_broadcast는 async-signal-safe가 아님
  • 처방: 시그널을 받는 전용 스레드가 sigwait로 기다리다 일반 문맥에서 close_q(). 스레드 생성 전에 pthread_sigmask
sequenceDiagram
    participant K as 시그널 (SIGINT)
    participant S as sigwait 전용 스레드
    participant Q as 메시지 큐
    participant W as producer · consumer 스레드
    Note over S,W: 모든 스레드에서 시그널을 block 하고 S만 sigwait로 대기
    K->>S: SIGINT 도착
    S->>Q: close_q() (일반 스레드 문맥)
    Q-->>W: broadcast: closed 확인 후 루프 종료
    W->>S: join

8. 종합 과제 — 계좌 이체 + 감시 스레드 (con_ex.c)

요구사항

처음 작성한 코드의 문제

설계

요약

  • 처방: 계좌마다 mutex, 이체는 번호 오름차순으로 두 락, 감시는 전체 락 0→9 순서로 합계, 스레드별 통계·seed, 종료는 atomic flag, 출력은 락 밖
  • 결과: (아래 결과)
flowchart LR
    T1["이체 스레드 ×4"] -- "from · to 두 락<br/>번호가 작은 순서" --> ACC
    M["감시 스레드"] -- "전체 락 0→9 순서<br/>잡고 합계 계산" --> ACC
    ACC[("계좌 10개<br/>계좌마다 mutex 1개")]
    MAIN["main"] -- "종료 flag (atomic)" --> T1
    MAIN -- "종료 flag (atomic)" --> M
    MAIN -- "join 후 통계 읽기" --> T1

결과 (10초 실행)

변이 테스트 — 락을 뺀 버전이 실제로 실패하는가

요약

  • 문제: 락을 뺀 버전이 실제로 실패하는지 확인
  • 처방: 이체 락 제거 / 락 순서 고정 제거 / 감시 락 제거 변형을 각각 시험
  • 결과: 이체 락 제거는 5회 모두 합계 틀림, 순서 제거는 5회 모두 데드락, 감시 락 제거는 10초 실행에서 불일치 감지

9. 정리 — 동시성 체크리스트

락 사용

스레드 수명

조건변수·함수 선택

검증


관련 노트